查看原文
其他

Android 如何将一帧图像渲染到屏幕?

AndroidPub 2022-09-13

作者:邓伟超
原文:https://zhuanlan.zhihu.com/p/351743856

本文将介绍 Android 的渲染机制。了解 Android 的渲染机制不论是对日常的开发,还是渲染问题排查都会有帮助。本文希望能带你了解 Android 是如何绘制并显示一帧图像的,同时会涉及 Perfetto UI 的使用,以及带来一些延伸的思考问题,感兴趣的可以在阅读的同时同步使用 Perfetto UI 进行尝试。

从一个经典问题说起

先看一个经典的例子,如何获取一个 View 的尺寸?

val w = view.width
val h = view.height

相信入门的 Android 开发者都知道,在这个情况下,这样调用是拿不到的:

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        val w = findViewById<View>(R.id.testButton).width
    }
}

所以大家都通常这样做:

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        findViewById<View>(R.id.testButton).post {
            val w = findViewById<View>(R.id.testButton).width
        }
    }
}

在 setContentView 后,findViewById 就能工作了,然后 post 一个 Runnable,在之后执行。

注意一下,虽然 findViewById 能正常工作了(Window 上的 ViewRootImpl 已经持有着这个 View 了),但是此时从你的 XML inflate 出来的 View,还没有被 attached 到 Window 上,因此这个 Runnable,会在 View 的一个队列里先放着:

public boolean post(Runnable action) {
    final AttachInfo attachInfo = mAttachInfo;
    if (attachInfo != null) {
        return attachInfo.mHandler.post(action);
    }
    // Postpone the runnable until we know on which thread it needs to run.
    // Assume that the runnable will be successfully placed after attach.
    getRunQueue().post(action);
    return true;
}

然后再在 onWindowAttached 里去丢到主线程的 MessageQueue 里执行:

void dispatchAttachedToWindow(AttachInfo info, int visibility) {
        mAttachInfo = info;
        
    // .....

        // Transfer all pending runnables.
        if (mRunQueue != null) {
            mRunQueue.executeActions(info.mHandler);
            mRunQueue = null;
        }
        performCollectViewAttributes(mAttachInfo, visibility);
        onAttachedToWindow();

        // ......
    }

我们知道,View 的 getWidth / getHeight 方法能获取正确的值的前提,是需要在它被 layout 后:具体可以见 setFrame(int left, int top, int right, int bottom) 方法,此方法是在 layout(int l, int t, int r, int b) 里调用的。

那么 layout 是在什么时候调用的呢?这时候就需要了解 Android 是如何绘制一帧的。先放出一些结论:

  • 上文 post 的 Runnable,因为在 setContentView 里的 requestLayout 里加入了 syncBarrier (具体可以见 ViewRootImpl 的 scheduleTraversals 方法里的 mHandler.getLooper().getQueue().postSyncBarrier()),那么将在下一帧的 Traversals 完成,onAttachedToWindow 后再次被 post 到 MessageQueue;

  • performTraversals 里,会执行 dispatchAttachedToWindow,也就是 View.post 的 Runnable 得以再次被 post(注意,不是立即执行,而是再被 post);

  • 下一帧的 Traversals 由 Vsync 驱动,Choreographer 收到 Vsync 消息后,在 doFrame 里处理 doTraversals ,移除 syncBarrier;

  • syncBarrier 的加入,让 post 在这个时间之后的 Message 会在移除 barrier 后才会执行;而在 ViewRootImpl 里 scheduleTraversals 里 post 的 mTraversalRunnable,是一个异步消息,即使遇到 barrier 也会优先执行。

因为上面的这些条件,能保证你 post 的 Runanble callback 在下一帧,也就是已经完成 messure, layout, draw 后才执行,因此在这个时候,你的 View 已经布局了,也就是能获取到它的宽高。

这个过程,我们尝试使用 Trace 追踪,代码如下:

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        TraceCompat.beginAsyncSection("post to get width"0)

        findViewById<View>(R.id.testButton).post {
            TraceCompat.beginSection("get width")
            val w = findViewById<View>(R.id.testButton).width
            TraceCompat.endSection()
            TraceCompat.endAsyncSection("post to get width"0)
        }
    }
}

Perfetto UI 里的图:

因为 get width 过程耗时太短 (39us),会比较难找,我这里标出来了。可以看到,post to get width 整个过程跨越 activityStart,到 activityResume,然后到一帧绘制完毕。

延伸问题

这里延伸一下,如果是在 setContentView 之前就调用 post,那么会怎样?比如:

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        window.decorView.post {
            val w = findViewById<View>(R.id.testButton)?.width
        }

        setContentView(R.layout.activity_main)
    }
}

这样还能正确获取 View 的 width 吗?

答案是可以的:跟上面分析一样,decorView 也是 View,但因为当前还没 attached 到 Window 上,所以你 post 的 Runnable 会先放到一个 Queue 里,然后再在 AttacthedToWindow 的时候再被 post;

View 的 post 和 postOnAnimation 方法有什么区别?

首先,post 方法是 post 到跟主线程绑定的一个 MessageQueue,如果在设置 syncBarrier 之前 post 的,那么会在下一帧执行前执行;如果是在设置 syncBarrier 之后 post 的,那么会在下一帧移除 syncBarrier 之后(通常是在 Traversals 之后)执行。

postOnAnimation 方法是 post 到 Choreographer 的 doCallback 里针对 CALLBACK_ANIMATION 类型的 Queue,也就是会在 Chereographer doFrame 的时候去执行。

/**
 * <p>Causes the Runnable to be added to the message queue.
 * The runnable will be run on the user interface thread.</p>
 *
 * @param action The Runnable that will be executed.
 *
 * @return Returns true if the Runnable was successfully placed in to the
 *         message queue.  Returns false on failure, usually because the
 *         looper processing the message queue is exiting.
 *
 * @see #postDelayed
 * @see #removeCallbacks
 */

public boolean post(Runnable action) {
    final AttachInfo attachInfo = mAttachInfo;
    if (attachInfo != null) {
        return attachInfo.mHandler.post(action);
    }
    // Postpone the runnable until we know on which thread it needs to run.
    // Assume that the runnable will be successfully placed after attach.
    getRunQueue().post(action);
    return true;
}
/**
 * <p>Causes the Runnable to execute on the next animation time step.
 * The runnable will be run on the user interface thread.</p>
 *
 * @param action The Runnable that will be executed.
 *
 * @see #postOnAnimationDelayed
 * @see #removeCallbacks
 */

public void postOnAnimation(Runnable action) {
    final AttachInfo attachInfo = mAttachInfo;
    if (attachInfo != null) {
        attachInfo.mViewRootImpl.mChoreographer.postCallback(
                Choreographer.CALLBACK_ANIMATION, action, null);
    } else {
        // Postpone the runnable until we know
        // on which thread it needs to run.
        getRunQueue().post(action);
    }
}

以下为 Choregrapher doFrame 里调用 Callbacks 的顺序:先是处理输入处理 INPUT(比如触控事件分发),然后执行 ANIMATION(Animation 的回调会在这里发生,在 Traversal 之前先设置好 View 的属性),再然后就是 INSETS_ANIMATION(为 Android 11 加入的 Inset 的 Animation),然后是重点的 TRAVERSAL(Measure,Layout 和 Draw),再然后就是 COMMIT(比如会用来 TrimMemory,见 ActivityThread)。

void doFrame(long frameTimeNanos, int frame) {
        // .....

        try {
            Trace.traceBegin(Trace.TRACE_TAG_VIEW, "Choreographer#doFrame");
            AnimationUtils.lockAnimationClock(frameTimeNanos / TimeUtils.NANOS_PER_MS);

            mFrameInfo.markInputHandlingStart();
            doCallbacks(Choreographer.CALLBACK_INPUT, frameTimeNanos);

            mFrameInfo.markAnimationsStart();
            doCallbacks(Choreographer.CALLBACK_ANIMATION, frameTimeNanos);
            doCallbacks(Choreographer.CALLBACK_INSETS_ANIMATION, frameTimeNanos);

            mFrameInfo.markPerformTraversalsStart();
            doCallbacks(Choreographer.CALLBACK_TRAVERSAL, frameTimeNanos);

            doCallbacks(Choreographer.CALLBACK_COMMIT, frameTimeNanos);
        } finally {
            AnimationUtils.unlockAnimationClock();
            Trace.traceEnd(Trace.TRACE_TAG_VIEW);
        }

        // ......
    }

基于以上事实,对不同的调用方式有不同的结果:

textView.post { // 0 }
textView.postOnAnimation { // 1}
textView.post { // 2 }
textView.text = "posted"

以上代码,三个 post 的 Runnable callback,发生的顺序是 0, 2, 1

而如果是这情况:

textView.text = "posted"
textView.post { // 0 }
textView.postOnAnimation { // 1}
textView.post { // 2 }

那么此时发生的顺序是 1, 0, 2

注意,以上代码不要在 Activity onCreate 里执行,等 App 运行稳定后,在某些条件触发(比如点击一个不会改变 UI 的 TextView)比较好。原因是前面提到的 postSyncBarrier 相关,在 onCreate 里,因为在 setContentView 里会有 postSyncBarrier 的执行,因此这情况得不到以上的测试结果。

关于 Choreographer 和 Vsync 的工作机制,将在下文里说。

Choreographer 和 Vsync

渲染流程

首先,需要知道,整个屏幕渲染的核心,是对图像数据的生产和消费。

生产和消费的对象,是 BufferQueue 里的 Buffer。

BufferQueue 很好理解,就是 Buffer 的 Queue(笑)。生产者使用 dequeueBuffer 取出可用的 Buffer,往里面填充图像数据(不论是用 CPU 还是 GPU 生产),然后 queueBuffer 把图像数据放回到 BufferQueue 里;消费者使用 requireBuffer 取出可用于屏幕显示的图像数据,然后用于显示,然后再把 Buffer release 回 BufferQueue。

因此整个渲染流程的核心就是 BufferQueue,要理解好渲染流程,就要了解好谁是生产者(Producer),谁是消费者(Consumer),以及 BufferQueue 是怎么运作的。

这里来自官网的一个图,尽管看上去内容有点多,但在里面其实可以很容易地找到生产者和消费者双方:

  • 生产者之一就是你的 App。不论你是使用 Android UI Framework 本身的软硬件绘制,还是使用播放器解码视频文件,还是从摄像头取出摄像数据,你的 App 都是图像数据的生产者。
  • 消费者是 SurfaceFlinger 和 HWC(Hardware Composer),对各生产者生产出来 Buffer 进行合成,然后显示到屏幕。
  • Surface 对象则是上述 Buffer 的抽象。所以你是用不着 dequeueBuffer 这样的操作的,很多 API 都有 setSurface 的方法。
  • 这里生产者和消费者都不是绝对的,看你在怎样参考系来看:即使在上述流程的生产侧,Camera 是一个生产者,对 Camera 产生的图像数据进行进一步加工(比如使用 OpenGL ES 处理)的流程,可以认为是一个消费者。

对 BufferQueue 理解了的话,那么可以延伸思考一下:

  • 生产者何时 dequeueBuffer,消费者何时 require Buffer?两者是怎么协调工作的?
  • BufferQueue 里可能会有多少个 Buffer?里面含有不同数目的 Buffer,对 App 的显示工作(具体来说,就是帧率)会有怎样的影响?

要了解这两个问题,就要了解:

  • Choreographer 和 Vsync
  • Triple Buffer,Double Buffer 和 Single Buffer。

Choreographer 和 Vsync

Choreographer 和 Vsync 共同解决生产者何时生产,消费者何时消费的问题。

试想一下这个情况:假如生产者和消费者的行为都是非常激进的,两者会轮流做生产和消费:生产者先生产,然后把 Buffer 放回到 BufferQueue 后消费者马上消费,然后生产者又马上拿出来生产,两者的行为不会有时间间隙,会持续这样进行。

这里看起来有没什么大问题呢?有。目前手机屏幕的刷新率在 60Hz ~ 120Hz 之间。也就是一秒时间内,最快也就刷新 120 次,要想产生让用户感到流畅的画面,一秒生产 / 消费 120 次就足够了,但如果以上述激进的方式来生产和消费,那么可能会产出很多无用功,带来功耗的提升和发热。

因此需要一个「协调者」来协调这个工作。Android 这里的协调者就是 Choreographer。既然要协调,那么肯定是需要有一个协调的依据,这个依据就是 Vsync 信号——也就是垂直同步信号(至于什么是垂直同步,那么这就涉及到显示器的知识了,有兴趣请自行查阅)。

Vsync 信号一般是由硬件产生的,每个 Vsync 信号之间的时间,就是每一帧生产 / 消费的间隙。Vsync 有两种,Vsync-app 和 Vsync-sf,前者用于告诉 Choreographer,是时候协调 app 生产了;后者用于告诉 SurfaceFlinger,是时候来消费合成并显示到屏幕了。

认识 Vsync-app 和 Vsync-sf 的最好方式,就是使用 Perfetto UI(前身是 Systrace)。关于这个工具的使用,可以先自行 Google ,这里不会做太详细的介绍。

对于 Android 9 以上手机,可以通过开发者选项里开启在通知栏显示 Systrace 的 Tile,这样可以在没有连接 USB 线使用 ADB 命令的情况下,也可以直接在手机上开启 Systrace 的开始和关闭,然后传输到电脑上进行进一步分析。

Perfetto UI 的界面跟 Systrace 很类似,但是操作方式变得友好太多了(比如支持触控板双指缩放、平移等)。在打开一个 trace file 后,你可以在左侧找到 SurfaceFlinger:

然后点击展开,找到 Vsync-app 和 Vsync-sf,你可以点击左侧的星星图标,这样可以固定到顶部。你可以固定多行,这样就能更方便地做对比。

从上图可以看到,Vsync-app 和 Vsync-sf 是几乎同时发生的(但因为有 Vsync Offset 的存在,每个手机可能都不一样)——注意,这里使用数字信号的变化来表示 Vsync 的发生,这意味着,从 1 ~ 0 或者从 0 ~ 1 都是发生了 Vsync 信号的意思:

接下来看看 Vsync-app 和 Vsync-sf 发生的时候,Choreographer 是怎么协调工作的。

这里先思考一下,为什么你的 app 需要重绘?既然需要重绘,那么就需要 app 的内容发生变化,这里的内容变化可以由多种情况下发生:

  • 用户通过物理按键或者触控的方式,产生了交互,app 需要响应交互做出相应的变化,比如改变按钮的文字,颜色,因为列表滚动了所以显示新的内容等;
  • 在一个动画过程里,每隔一段时间触发一次内容的改变,比如改变背景颜色,View 的位置等;
  • 播放视频等场景;

举因为用户触控导致重绘的场景做例子,在收到 Vsync-app 信号后,Choreographer 做了以下工作:

  • 分派触控事件,此时可能检测到 Click 事件的发生,然后 scheduleTraversals
  • 执行 Animation 的回调,比如改变 View 的属性等
  • 进行 Traversals,视情况会完整或者不完整地走完我们熟悉的:Measure、Layout 和 Draw

以上都是发生在主线程,其中 Draw:

  • 如果开启了硬件(GPU)加速(现在的 Android 默认都是开了的),那么不会走 CPU 绘制,这里会产生用来描述绘制行为的 DisplayList;
  • 没开启硬件加速的话,会调用 libSkia 来绘制;

注意不要混淆硬件加速和 Software Layer / Hardware Layer 的概念。在开启硬件加速后,你可以使用 Hardware Layer 来对内容本身很少改变的 View 来做显存上的缓存;而不开启硬件加速,或者不支持 Hardware Layer 的话,可以选择使用 Software Layer 来告诉 UI Framework 帮你构造 Bitmap 缓存。对于不使用 Layer 的情况,View 该怎么绘制就怎么绘制。

在开启硬件(GPU)加速后,主线程就会把 DisplayList 同步给 RenderThread,RenderThread 这里可以做一些优化的操作(比如 Reordering),然后提交给 GPU 进行绘制(这里会进行 dequeueBuffer),当绘制完毕后(eglSwapBuffers),通过 queueBuffer 把 Buffer 放回到 BufferQueue 里。

在 Vsync-sf 发生后,SurfaceFlinger 就会跟 HAL 打交道,负责把图像数据渲染到 Display 上,下文会详细说这方面。

SurfaceFlinger & HAL

SurfaceFlinger 作为上述过程里的消费者,负责跟 HardwareComposer 打交道,把各个 Layer 的数据进行合成,然后交由 HardwareComposer 来进行显示。

有一些细节需要说明一下。

SurfaceFlinger 里有 Layer 的概念,其实就是对应于 App 侧的 Window:

在一帧需要合成的时候,SurfaceFlinger 负责跟 HardwareComposer(HWC) 通讯,把各个 Layer 都输出到显示设备。但是不是所有 Layer 都能直接交给 HWC,对于能直接交给 HWC 合成的,HWC 会告诉 SurfaceFlinger 这个 Layer 类型是 OVERLAY;对于不能直接交给 HWC 合成的多个 Layer(比如 Buffer 里数据类型不支持),HWC 会让 SurfaceFlinger 通过 OpenGL ES 合成一个 Layer,然后再移交给 HWC。

HardwareComposer 属于 Hardware Abstract Layer(HAL),由每个 OEM 来实现,所以不同的手机厂商,上述的合成过程也有可能有区别。

可以通过这个命令输出当前 SurfaceFlinger 的信息:

adb shell dumpsys SurfaceFlinger

内容比较多,有兴趣可以查阅相关资料逐一分析,想要知道当前合成的 Layer 都有哪些的话,可以关注下面这样子的表格:

可以看到包括 StatusBar 在内的一个个 Layer,以及我们应用(com.example.myapplication/com.example.myapplication.MainActivity#0) 的 Layer 的相关信息,甚至是区域位置都能清楚地知道。

延伸阅读:SurfaceView 应用的渲染模式

SurfaceView 其实是在另一个 Window 上显示的,而且是把当前所在 Activity 的 Window 挖了个洞,然后放到它的下方:

所以对于这情况,你可以在 dump SurfaceFlinger 信息后看到,除了 MainActivity 本体,也有一个名字以 SurfaceView 开头的 Layer,同时它的 Z order 是 -2,都比其他要低(当然要提高也很简单,设置一下 setZOrderOnTop(true))即可。

另外,可以思考一下,GLSurfaceView 里有一个 RenderMode,你可以设置为:

  • RENDERMODE_WHEN_DIRTY:只有你调用 requestRender 才渲染一帧
  • RENDERMODE_CONTINUOUSLY:一帧绘制完了后继续绘制下一帧

可以理解到,在 RENDERMODE_CONTINUOUSLY 之下,绘制会一直持续,如果你绘制的内容是跟时间相关的(比如做一个动画),那么需要在绘制每一帧的时候,需要准确知道当前的 PST,然后计算出相关的属性,然后绘制。那么这里可能会造成浪费性能。

可以从这里看到 GLThread 绘制一帧的时间都是相近的,但不是跟 Vsync 同步的。

如果想跟 Vsync 同步,那么你可以选择使用 RENDERMODE_WHEN_DIRTY,然后通过 View.postOnAnimation (或者直接在主线程里用 Choreographer.getInstance().postFrameCallback)里调用 requesetRender 来绘制:

思考一下,调用 View.post 能实现跟 Vsync 同步么?为什么?答案是不行的。除非你这么做:

// Renderer
override fun onDrawFrame(gl: GL10?) {
    GLES20.glClearColor(1f0f0f1f)
    GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT)

    // other gl commands

    this@MainActivity.window.decorView.post {
        this@MainActivity.window.decorView.requestLayout()
        this@MainActivity.window.decorView.post {
            findViewById<GLSurfaceView>(R.id.gl_surface_view).requestRender()
        }
    }
}

因为 syncBarrier 的存在,通过 View.post 也能实现跟着 Vsync 信号绘制一帧。

总结

希望通过以上的分析,你能对 Android 的渲染机制有一定程度的了解:

  • 能了解 BufferQueue 的运作方式
  • 能理解 Choreographer 和 Vsync 的出现是要解决怎样的问题
  • 能理解 SurfaceFlinger 和 HAL 作为消费方是怎样处理 Buffer 的
  • 能把 Android 的知识融会贯通到 iOS 端
  • 如果还有其他疑问,欢迎阅读以下的参考资料。

参考资料

  • https://www.androidperformance.com/2019/05/28/Android-Systrace-About/
  • https://source.android.google.cn/devices/graphics
  • https://developer.apple.com/videos/play/tech-talks/10855
  • https://www.youtube.com/watch?v=zdQRIYOST64&t=791s&ab_channel=AndroidDevelopers



---END---


推荐阅读

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存